﻿using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Web;
using BlogEngine.Core;

namespace Neovolve.BlogEngine.Web
{
    /// <summary>
    /// The <see cref="CSUrlRedirector"/>
    /// class is used to redirect urls from Community Server locations to BlogEngine locations.
    /// </summary>
    /// <remarks>
    /// <para>
    /// This module will redirect Community Server pages, posts, month post lists, day post lists, tags, syndication and tagged syndication urls
    /// to the most appropriate location in BlogEngine. BlogEngine relies on its own friendly written urls when the urls are processed. This module
    /// cannot rewrite Community Server urls as BlogEngine will not be able to understand what the requested resource is. The module must
    /// redirect requests to the equivalent BlogEngine friendly url so that BlogEngine url writing can occur such that resources are resolved correctly.
    /// </para>
    /// <para>
    /// In Community Server, tags and categories are the same thing and it supports tag filtering by allowing mutliple tags to be defined.
    /// BlogEngine supports both tags and categories as separate entities and has a concept of a category hierarchy, but doesn't support tag filtering.
    /// When a Community Server url is encountered where multiple tags are defined, the first Community Server tag in the url that can be matched
    /// to either a BlogEngine category or tag will be the resource used to response to the client request. Preference is given to categories over tags.
    /// </para>
    /// </remarks>
    public class CSUrlRedirector : IHttpModule
    {
        /// <summary>
        /// Defines the format for page urls (/pages/*.aspx).
        /// </summary>
        private static readonly Regex CSPageFormat = new Regex(
            @"/pages/(?<PageTitle>.+)\.aspx", RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Defines the format for post urls (/archive/YYYY/MM/DD/*.aspx).
        /// </summary>
        private static readonly Regex CSPostFormat =
            new Regex(
                @"/archive/(?<PostYear>[0-9][0-9][0-9][0-9])/(?<PostMonth>[0-1][0-9])/(?<PostDay>[0-3][0-9])/(?<PostTitle>.+)\.aspx",
                RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Defines the format for atom syndication urls (/atom.aspx).
        /// </summary>
        private static readonly Regex CSSyndicationAtomFormat = new Regex(
            @"/atom.aspx", RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Defines the format for rss syndication urls (/rss.aspx).
        /// </summary>
        private static readonly Regex CSSyndicationRssFormat = new Regex(
            @"/rss.aspx", RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Defines the format for tag post list urls (/archive/tags/*/default.aspx).
        /// </summary>
        private static readonly Regex CSTagPostListFormat = new Regex(
            @"(/archive)*/tags/(?<TagList>.+)/default\.aspx",
            RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);
        
        /// <summary>
        /// Defines the format for tag syndication urls (?Tags=*&amp;AndTags=1).
        /// </summary>
        private static readonly Regex CSTagSyndicationFormat = new Regex(
            @"\?Tags=(?<TagList>.+)&AndTags=1", RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Defines the format for year/month/day post list urls (/archive/YYYY/MM/DD.aspx).
        /// </summary>
        private static readonly Regex CSYearMonthDayPostListFormat =
            new Regex(
                @"/archive/(?<PostYear>[0-9][0-9][0-9][0-9])/(?<PostMonth>[0-1][0-9])/(?<PostDay>[0-3][0-9])(?=\.aspx)",
                RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Defines the format for year/month post list urls (/archive/YYYY/MM.aspx).
        /// </summary>
        private static readonly Regex CSYearMonthPostListFormat =
            new Regex(
                @"/archive/(?<PostYear>[0-9][0-9][0-9][0-9])/(?<PostMonth>[0-1][0-9])(?=\.aspx)",
                RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Defines the format for identifying illegal characters.
        /// </summary>
        private static readonly Regex IllegalCharacters = new Regex(
            @"(_[0-9A-Z]{4,}_|[^_A-Za-z0-9])+",
            RegexOptions.Singleline | RegexOptions.Compiled | RegexOptions.IgnoreCase);

        /// <summary>
        /// Stores the trace source for the class.
        /// </summary>
        private static readonly TraceSource Tracer = new TraceSource("CSUrlRedirector");

        /// <summary>
        /// Disposes of the resources (other than memory) used by the module that implements <see cref="T:System.Web.IHttpModule"/>.
        /// </summary>
        public void Dispose()
        {
            // Nothing to do here
        }

        /// <summary>
        /// Initializes a module and prepares it to handle requests.
        /// </summary>
        /// <param name="context">An <see cref="T:System.Web.HttpApplication"/> that provides access to the methods, properties, and events common to all application objects within an ASP.NET application</param>
        public void Init(HttpApplication context)
        {
            context.BeginRequest += BeginRequestHandler;
        }

        /// <summary>
        /// Begins the request handler.
        /// </summary>
        /// <param name="sender">The sender.</param>
        /// <param name="e">The <see cref="System.EventArgs"/> instance containing the event data.</param>
        private static void BeginRequestHandler(Object sender, EventArgs e)
        {
            try
            {
                HttpContext context = ((HttpApplication)sender).Context;
                String url = context.Request.RawUrl.ToUpperInvariant();

                // Get the format of the url
                CSUrlFormat urlFormat = GetUrlFormat(url);

                // Determine if there is a new url available for this resource
                String newUrl = GetNewUrl(url, urlFormat);

                Tracer.TraceInformation("BeginRequestHandler: Original Url - {0}, Detected Format - {1}, New Url - {2}", new object[] { url, urlFormat, newUrl });

                // Check if there is a new url to use
                if (String.IsNullOrEmpty(newUrl) == false)
                {
                    // Redirect the client to this location
                    context.Response.Redirect(newUrl);
                }
            }
            catch (ThreadAbortException)
            {
                // When a redirection occurs, the current thread is aborted
                // This throw is done to avoid thread aborts from being traced below
                throw;
            }
            catch (Exception ex)
            {
                Tracer.TraceInformation(ex.ToString());

                throw;
            }
        }

        /// <summary>
        /// Calculates the month post list URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <returns>A <see cref="String"/> if a new url is available, otherwise <see cref="string.Empty">String.Empty</see>.</returns>
        private static String CalculateMonthPostListUrl(String url)
        {
            Match match = CSYearMonthPostListFormat.Match(url);
            Debug.Assert(match.Success, "The expression failed to find a match");

            // Get the year value from the url
            String year = match.Groups["PostYear"].Value;
            Debug.Assert(String.IsNullOrEmpty(year) == false, "Failed to extract the post year from the url");

            // Get the month value from the url
            String month = match.Groups["PostMonth"].Value;
            Debug.Assert(String.IsNullOrEmpty(month) == false, "Failed to extract the post month from the url");

            // Create the BlogEngine url for this resource
            return String.Concat(
                Utils.RelativeWebRoot, year, "/", month, "/default", BlogSettings.Instance.FileExtension);
        }

        /// <summary>
        /// Calculates the page URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <returns>A <see cref="String"/> if a new url is available, otherwise <see cref="string.Empty">String.Empty</see>.</returns>
        private static String CalculatePageUrl(String url)
        {
            Match pageMatch = CSPageFormat.Match(url);
            Debug.Assert(pageMatch.Success, "The expression failed to find a match");
            Debug.Assert(pageMatch.Groups.Count >= 0, "Incorrect number of matches found by the expression");

            // Get the page title from the sub-match
            String csPageTitle = StripIllegalCharacters(pageMatch.Groups["PageTitle"].Value);
            Debug.Assert(String.IsNullOrEmpty(csPageTitle) == false, "Failed to extract the page title from the url");

            // Find the page with this title
            Page page = Page.Pages.Find(
                delegate(Page p)
                {
                    // We need to check for a match on the slug as well as the title because they may be different
                    String bePageSlug = StripIllegalCharacters(p.Slug);

                    // Check if the page title matches the slug
                    if (csPageTitle.Equals(bePageSlug, StringComparison.OrdinalIgnoreCase))
                    {
                        // We have found a match on the slug
                        return true;
                    }

                    // Get the title of the page
                    String bePageTitle = StripIllegalCharacters(p.Title);

                    // Check for a match on the title
                    return csPageTitle.Equals(bePageTitle, StringComparison.OrdinalIgnoreCase);
                });

            // Check if we have found a page
            if (page != null)
            {
                // Successful page match, return the link
                return page.RelativeLink;
            }

            // We didn't find the page
            return String.Empty;
        }

        /// <summary>
        /// Calculates the post URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <returns>A <see cref="String"/> if a new url is available, otherwise <see cref="string.Empty">String.Empty</see>.</returns>
        private static String CalculatePostUrl(String url)
        {
            // Get the post data from the url
            DateTime date = ExtractPostDate(url);
            Debug.Assert(date != DateTime.MinValue, "Correct date was not extracted from the url");

            Match urlMatch = CSPostFormat.Match(url);
            Debug.Assert(urlMatch.Success, "The expression failed to find a match");

            String csPostTitle = StripIllegalCharacters(urlMatch.Groups["PostTitle"].Value);
            Debug.Assert(String.IsNullOrEmpty(csPostTitle) == false, "Failed to extract the post title from the url");

            // Find the post with this title and post date
            Post post = Post.Posts.Find(
                delegate(Post p)
                {
                    // Exit if the post year is not the same
                    if (p.DateCreated.Year
                        != date.Year)
                    {
                        return false;
                    }

                    // Exit if the post month is not the same
                    if (p.DateCreated.Month
                        != date.Month)
                    {
                        return false;
                    }

                    // Exit if the post day is not the same
                    if (p.DateCreated.Day
                        != date.Day)
                    {
                        return false;
                    }

                    // Check the slug as well as the title as they may not be the same
                    String bePostSlug = StripIllegalCharacters(p.Slug);

                    // Check if the post title matches the slug
                    if (csPostTitle.Equals(bePostSlug, StringComparison.OrdinalIgnoreCase))
                    {
                        // We have found a match on the slug
                        return true;
                    }

                    String bePostTitle = StripIllegalCharacters(p.Title);

                    // Check for a match on the title
                    return csPostTitle.Equals(bePostTitle, StringComparison.OrdinalIgnoreCase);
                });

            // Check if we have found a post
            if (post != null)
            {
                // Return the link to the post
                return post.RelativeLink;
            }

            // We didn't find a post
            return String.Empty;
        }

        /// <summary>
        /// Calculates the syndication URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <param name="syndicationType">The type of the syndication.</param>
        /// <returns>A <see cref="String"/> value.</returns>
        private static String CalculateSyndicationUrl(String url, String syndicationType)
        {
            // Create the base syndication url for BlogEngine
            String syndicationUrl = String.Concat(Utils.RelativeWebRoot, "syndication.axd?format=", syndicationType);
            
            // Get the set of tag filters from the Community Server url
            String[] tags = ParseTagsFromUrl(url, CSTagSyndicationFormat);

            // Check if there are any tags defined
            if (tags != null
                && tags.Length > 0)
            {
                // Get the first available category from the tag set
                Category category = FindFirstCategoryMatch(tags);

                // Check if there is a category
                if (category != null)
                {
                    // Append the category to the base address
                    return String.Concat(syndicationUrl, "&category=", category.Id.ToString());
                }
            }

            // Return the base syndication url - no tag filtering applied
            return syndicationUrl;
        }

        /// <summary>
        /// Calculates the tag post list URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <returns>A <see cref="String"/> if a new url is available, otherwise <see cref="string.Empty">String.Empty</see>.</returns>
        private static String CalculateTagPostListUrl(String url)
        {
            // Community Server supports multiple tags being specified for extended filtering
            // BlogEngine only supports one, however it supports both categories and tags
            // Order of precidence will be to categories, then tags
            // For each tag found in the CS url, loop through until the first category is identified
            // If no category match is found, loop through and attempt to match a tag
            // For example, /tags/.Net/default.aspx may become /category/Net.aspx or /tag=.net

            // Get the Community Server tags from the url
            String[] csTags = ParseTagsFromUrl(url, CSTagPostListFormat);

            // Search for a category match on the tag name
            Category categoryMatch = FindFirstCategoryMatch(csTags);

            // Check if we found a category to use
            if (categoryMatch != null)
            {
                // We have found a category id to use
                return String.Concat(
                    Utils.RelativeWebRoot,
                    "category/",
                    Utils.RemoveIllegalCharacters(categoryMatch.Title),
                    BlogSettings.Instance.FileExtension);
            }

            // Search for a tag match on the tag name
            String tagName = FindFirstTagMatch(csTags);

            // Check if we found a tag to use
            if (String.IsNullOrEmpty(tagName) == false)
            {
                // We have found a tag
                return String.Concat(Utils.RelativeWebRoot, "?tag=/", HttpUtility.UrlEncode(tagName));
            }

            // No tag or category found
            return String.Empty;
        }

        /// <summary>
        /// Extracts the date.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <returns></returns>
        private static DateTime ExtractPostDate(String url)
        {
            // Get the match
            Match match = CSPostFormat.Match(url);

            if (match.Success)
            {
                Int32 year = Int32.Parse(match.Groups["PostYear"].Value, CultureInfo.InvariantCulture);
                Int32 month = Int32.Parse(match.Groups["PostMonth"].Value, CultureInfo.InvariantCulture);
                Int32 day = Int32.Parse(match.Groups["PostDay"].Value, CultureInfo.InvariantCulture);

                return new DateTime(year, month, day);
            }

            return DateTime.MinValue;
        }

        /// <summary>
        /// Finds the first category id match.
        /// </summary>
        /// <param name="csTags">The cs tags.</param>
        /// <returns>A <see cref="Category"/> or <c>null</c> if no category match is found.</returns>
        private static Category FindFirstCategoryMatch(String[] csTags)
        {
            // Loop through each of the categories available in the site
            for (Int32 categoryIndex = 0; categoryIndex < Category.Categories.Count; categoryIndex++)
            {
                Category category = Category.Categories[categoryIndex];

                // Loop through each tag to find a match
                for (Int32 csTagIndex = 0; csTagIndex < csTags.Length; csTagIndex++)
                {
                    String csTag = csTags[csTagIndex];

                    // Check if this Community Server tag matches this BlogEngine category
                    if (category.Title.Equals(csTag, StringComparison.OrdinalIgnoreCase))
                    {
                        return category;
                    }
                }
            }

            // We didn't find a category
            return null;
        }

        /// <summary>
        /// Finds the first tag match using the provided Community Server tags.
        /// </summary>
        /// <param name="csTags">The cs tags.</param>
        /// <returns></returns>
        private static String FindFirstTagMatch(String[] csTags)
        {
            // Get the set of unique tags currently in BlogEngine
            List<String> postTags = GetExistingPostTags();

            // Loop through each of the tags available in BlogEngine
            for (Int32 postIndex = 0; postIndex < postTags.Count; postIndex++)
            {
                String postTag = postTags[postIndex];

                // Loop through each Community Server tag provided
                for (Int32 csTagIndex = 0; csTagIndex < csTags.Length; csTagIndex++)
                {
                    String csTag = csTags[csTagIndex];

                    // Check if this Community Server tag matches this BlogEngine tag
                    if (postTag.Equals(csTag, StringComparison.OrdinalIgnoreCase))
                    {
                        return postTag;
                    }
                }
            }

            // We didn't find a tag
            return String.Empty;
        }

        /// <summary>
        /// Gets the day post list URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <returns>A <see cref="String"/> if a new url is available, otherwise <see cref="string.Empty">String.Empty</see>.</returns>
        private static String GetDayPostListUrl(String url)
        {
            Match match = CSYearMonthDayPostListFormat.Match(url);
            Debug.Assert(match.Success, "The expression failed to find a match");

            // Get the year value from the url
            String year = match.Groups["PostYear"].Value;
            Debug.Assert(String.IsNullOrEmpty(year) == false, "Failed to extract the post year from the url");

            // Get the month value from the url
            String month = match.Groups["PostMonth"].Value;
            Debug.Assert(String.IsNullOrEmpty(month) == false, "Failed to extract the post month from the url");

            // Get the day value from the url
            String day = match.Groups["PostDay"].Value;
            Debug.Assert(String.IsNullOrEmpty(day) == false, "Failed to extract the post day from the url");

            // Return the BlogEngine url for this resource
            return String.Concat(
                Utils.RelativeWebRoot, year, "/", month, "/", day, "/default", BlogSettings.Instance.FileExtension);
        }

        /// <summary>
        /// Gets the existing post tags.
        /// </summary>
        /// <returns></returns>
        private static List<String> GetExistingPostTags()
        {
            List<String> tags = new List<String>();

            // Loop through each post
            for (Int32 postIndex = 0; postIndex < Post.Posts.Count; postIndex++)
            {
                Post post = Post.Posts[postIndex];

                // Check if the post is visible
                if (post.IsVisible)
                {
                    // Loop through each tag in the post
                    for (Int32 tagIndex = 0; tagIndex < post.Tags.Count; tagIndex++)
                    {
                        String tag = post.Tags[tagIndex];

                        // Check if the tag has already been added
                        if (tags.Contains(tag) == false)
                        {
                            // We found a new tag
                            tags.Add(tag);
                        }
                    }
                }
            }

            // Return the set of tags
            return tags;
        }

        /// <summary>
        /// Gets the new URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <param name="urlFormat">The URL format.</param>
        /// <returns></returns>
        private static String GetNewUrl(String url, CSUrlFormat urlFormat)
        {
            switch (urlFormat)
            {
                case CSUrlFormat.Unknown:

                    // This url is not known so there is nothing we can do with it
                    return String.Empty;

                case CSUrlFormat.Post:

                    return CalculatePostUrl(url);

                case CSUrlFormat.Page:

                    return CalculatePageUrl(url);

                case CSUrlFormat.MonthPostList:

                    return CalculateMonthPostListUrl(url);

                case CSUrlFormat.DayPostList:

                    return GetDayPostListUrl(url);

                case CSUrlFormat.TagPostList:

                    return CalculateTagPostListUrl(url);

                case CSUrlFormat.Rss:

                    return CalculateSyndicationUrl(url, "rss");

                case CSUrlFormat.Atom:

                    return CalculateSyndicationUrl(url, "atom");

                default:

                    throw new ArgumentOutOfRangeException("urlFormat");
            }
        }

        /// <summary>
        /// Gets the format of the url.
        /// </summary>
        /// <param name="url">The url.</param>
        /// <returns>A <see cref="CSUrlFormat"/> value.</returns>
        private static CSUrlFormat GetUrlFormat(String url)
        {
            // Exit early if there isn't a value to check
            if (String.IsNullOrEmpty(url))
            {
                return CSUrlFormat.Unknown;
            }

            if (CSYearMonthDayPostListFormat.IsMatch(url))
            {
                return CSUrlFormat.DayPostList;
            }

            if (CSYearMonthPostListFormat.IsMatch(url))
            {
                return CSUrlFormat.MonthPostList;
            }

            if (CSTagPostListFormat.IsMatch(url))
            {
                return CSUrlFormat.TagPostList;
            }

            if (CSPostFormat.IsMatch(url))
            {
                return CSUrlFormat.Post;
            }

            if (CSPageFormat.IsMatch(url))
            {
                return CSUrlFormat.Page;
            }

            if (CSSyndicationRssFormat.IsMatch(url))
            {
                return CSUrlFormat.Rss;
            }

            if (CSSyndicationAtomFormat.IsMatch(url))
            {
                return CSUrlFormat.Atom;
            }

            return CSUrlFormat.Unknown;
        }

        /// <summary>
        /// Parses the tags from URL.
        /// </summary>
        /// <param name="url">The URL.</param>
        /// <param name="formatExpression">The format expression.</param>
        /// <returns></returns>
        private static String[] ParseTagsFromUrl(String url, Regex formatExpression)
        {
            Match tagMatch = formatExpression.Match(url);

            // Parse out the set of tags
            String tagSet = tagMatch.Groups["TagList"].Value;
            String[] csTags = tagSet.Split(new[] { '/' });

            // Loop through each tag to replace + with a space
            for (Int32 index = 0; index < csTags.Length; index++)
            {
                csTags[index] = HttpUtility.UrlDecode(csTags[index]);
            }

            return csTags;
        }

        /// <summary>
        /// Strips the illegal characters.
        /// </summary>
        /// <param name="text">The text.</param>
        /// <returns></returns>
        private static String StripIllegalCharacters(String text)
        {
            // Check if there is any text to work with
            if (String.IsNullOrEmpty(text))
            {
                return text;
            }

            const String ReplacementCharacter = " ";

            // Replace any illegal character in the string
            String newText = IllegalCharacters.Replace(text, ReplacementCharacter);

            return newText.Trim().ToLowerInvariant();
        }
    }
}